Skip to content

feat(vmm): run virtio-net on vhost-net with configurable queue pairs - #1145

Open
kvinwang wants to merge 2 commits into
nextfrom
feat/vmm-vhost-multiqueue
Open

feat(vmm): run virtio-net on vhost-net with configurable queue pairs#1145
kvinwang wants to merge 2 commits into
nextfrom
feat/vmm-vhost-multiqueue

Conversation

@kvinwang

@kvinwang kvinwang commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

A CVM's virtio-net NIC cannot exceed one core's worth of packet processing, no
matter how many vCPUs it has. The VMM builds -netdev bridge,... with no
vhost=, and a device line hardcoded to virtio-net-pci,netdev=net0,mac=...
with no mq=on. Every packet is drained by QEMU's single main-loop thread:

ceiling ≈ 1 core ÷ per-packet main-loop cost

Sampled on a production CVM (phala-tdx-prod7, pid 3543413, 16 vCPU / 32 GiB,
-netdev bridge,id=net0,br=dstack-br0):

31,822 pps in, 0 drops in the sample window, qemu main thread 91%, total 721%
→ 0.91 core / 31,822 pps = 28.6 µs/packet → ceiling ≈ 35k pps

That matches the reported symptom exactly — clean at 31k pps, 36% loss at 40k.
The three busy TAPs on that host had lifetime drop rates of 26.7% / 19.3% /
17.8%, hundreds of millions of packets each. The drops are at the TAP, so
guest-side rx_dropped and softnet_stat stay at zero and the cliff looks
like a network fault rather than a host CPU limit. Outbound guest traffic uses
the same thread, so a chatty guest pays it twice.

docs/bridge-networking.md claimed this was unavoidable: "vhost-net ... is
not enabled for bridge mode. TDX encrypts guest memory, which prevents the
host kernel from performing DMA-based packet offload." That is wrong. A TDX
guest's virtio rings and buffers live in shared, unencrypted memory precisely
so a host-side backend can reach them — which is why vhost-vsock-pci, which
dstack has always used, works.

Fix

Two settings on [cvm.networking], defaulting to vhost on and one queue pair,
each overridable per VM through the deployment RPC:

[cvm]
max_net_queues = 16         # ceiling for both the default and a request

[cvm.networking]
vhost = true

Queue pairs are not a node setting: they default to the VM's vCPU count, capped
at max_net_queues, because the useful number follows the VM rather than the
host. A deployment overrides that per VM.

vmm-cli.py deploy ... --net bridge --net-queues 4
vmm-cli.py deploy ... --net bridge --net-no-vhost

vectors is derived, never configured: 2N + 2. A single queue pair emits no
mq=on or vectors= at all, so a 1-vCPU VM and any VM deployed with
--net-queues 1 keep the historical command line byte for byte.

Each backend needed different plumbing:

Mode netdev notes
user unchanged no vhost backend, no multiqueue; a node-wide default is ignored here rather than failing the launch
bridge tap,br=,helper=,vhost=on QEMU's bridge netdev accepts neither vhost= nor queues=; the same setuid qemu-bridge-helper works behind a tap netdev, so the VMM still needs no CAP_NET_ADMIN
bridge, >1 queue tap,ifname=,queues=N the helper returns one descriptor, so netd creates a persistent multi_queue TAP — now also for unfiltered nodes
bridge + libvirt filter tap,ifname=,vhost=on the hardcoded vhost=off is now the configured value
macvtap tap,fds=a:b,vhost=on the launcher opens /dev/tapN once per queue; netd creates the link with matching numtxqueues/numrxqueues
custom verbatim the operator owns the netdev string; queues still drives the device line, which is what makes a hand-written netdev usable with multiqueue

Because macvtap now consumes several descriptors per NIC, the fd layout moved
into one macvtap_fd_layout() that both the launcher's open list and the
-netdev arguments derive from, replacing the 3 + index convention the two
sites previously duplicated.

QEMU aborts when vhost=on cannot open /dev/vhost-net, and it does so from
inside the launcher where the reason is easy to miss. The VMM warns when the
device node is missing, but deliberately does not pre-flight its own access to
it: QEMU need not share the VMM's credentials, and with vhost defaulting to on,
a VMM that is merely not in the kvm group would otherwise refuse every
deployment on a host where QEMU can open the device fine. stat needs no
permission on the device, so the check stays a statement about the host.

Neither field affects mr_config_id, which covers only the compose hash and
instance info, so retuning a NIC does not change app identity.

Verification

All five configurations deployed as real TDX CVMs on phala-tdx-lab (kernel
6.8, QEMU 8.2.2+tdx1.1, guest image dstack-0.6.0), against an isolated bridge
and a dedicated VMM instance.

Generated command lines, read back from /proc/<qemu>/cmdline:

A default        tap,id=net0,br=dstack-perf0,helper=/usr/lib/qemu/qemu-bridge-helper,vhost=on
                 virtio-net-pci,netdev=net0,mac=c6:29:aa:27:f7:05
B --net-no-vhost bridge,id=net0,br=dstack-perf0
                 virtio-net-pci,netdev=net0,mac=ba:6e:f8:29:d5:56
C --net-queues 4 tap,id=net0,ifname=dt4a44fb6e472b,script=no,downscript=no,vhost=on,queues=4
                 virtio-net-pci,netdev=net0,mac=4a:ea:3d:87:02:f1,mq=on,vectors=10
D macvtap, 2q    tap,id=net0,fds=3:4,vhost=on
                 virtio-net-pci,netdev=net0,mac=f6:c6:a1:17:e4:2c,mq=on,vectors=6
E libvirt filter tap,id=net0,ifname=dt851b876fcf4a,script=no,downscript=no,vhost=on

Host and guest state:

A (default) B (no vhost) C (4 queues) D (macvtap 2q)
guest ethtool -l eth0 combined 1 1 4 n/a (see below)
vhost worker threads in the QEMU thread group 2 1 5 3
DHCP lease + ssh into guest yes yes yes

The worker count is net queue pairs + 1 for vhost-vsock in every case, which
is what confirms the guest actually negotiated that many queue pairs.

netd created what was asked for, and cleaned up on VM removal:

dt4a44fb6e472b: tun type tap ... multi_queue numqueues 4 persist on user kvin
dt7472999c6242@dstack-perf0: macvtap mode bridge ... numtxqueues 2 numrxqueues 2
/proc/212991/fd/3 -> /dev/tap1759
/proc/212991/fd/4 -> /dev/tap1759      # two opens, two queues

After removing all five VMs: no dt* interfaces and no nwfilter bindings left.

Filtering still works with vhost on. docs/libvirt-network-filter.md said
flipping this bit needed "equivalent filter integration tests", so from inside
VM E, with clean-traffic bound to the TAP and vhost=on:

ping -c 3 10.90.0.1                       → 3 received, 0% loss
ip addr add 10.90.0.222/24 dev eth0
ping -c 3 -I 10.90.0.222 10.90.0.1        → 0 received, 100% loss

The nwfilter binding lives on the host TAP interface, so packets traverse it
whether QEMU or a vhost worker wrote them.

Node policy, over the RPC:

--net-queues 9         → networking queues must not exceed 8 on this node
--net user --net-queues 4 → user-mode networking does not support multiple queues

Throughput, 64-byte UDP host→guest via kernel pktgen, same 8 vCPU / 8 GiB CVM
shape on the same host (the lab host was running other tenants' CVMs, so treat
the absolute rates as noisy and the CPU columns as the signal):

target A: helper + vhost, 1q B: legacy netdev, no vhost C: netd + vhost, 4q
200k 0% loss, main 0% 0% loss, main 90% 0% loss, main 0%
500k 17.8% loss, main 0% 0.3% loss, main 93% 0% loss, main 0%
1M 26.6% loss, main 0% 5.2% loss, main 91% 0% loss, main 0%

The main thread goes from saturated to idle — that is the wall coming down, and
a whole core returned to the tenant. Note that vhost alone relocates the
ceiling rather than removing it: with one queue the guest's single receive queue
becomes the limit and drops reappear at a higher rate (the guest was at 99% CPU
in the 500k row). Multiqueue is what removes them. Hence vhost on by default,
queues raised deliberately.

A/B on the customer's own host (phala-tdx-prod7, production short-connection
workload, 4 alternating rounds to suppress noise) measured earlier in this
investigation: 10,922 → 13,933 conn/s (+27.6%), with the main thread going
94% → 0%.

Review follow-ups

A self-review pass found seven issues, all fixed in this branch:

  • netd fail-open. Making filter optional so netd can build unfiltered
    multiqueue TAPs meant mode = "libvirt" with an explicit filter = "" no
    longer failed — netd skipped nwfilter-binding-create and every VM booted
    onto an unbound TAP, where it previously refused to start. Config load now
    rejects an empty filter in libvirt mode.
  • One-shot mode. one_shot.rs still gated its "cannot manage TAP lifecycle"
    error on network_filter.mode == Libvirt, so queues > 1 on a bridge emitted
    -netdev tap,ifname=dt…,queues=4 for a TAP nobody created. It now uses the
    same needs_netd_interface predicate as the server path.
  • Tuning forced a mode. --net-queues 4 alone was rejected despite the
    flag's "default: use global config" help, and on a node whose default backend
    is not in allowed_network_modes, restating the mode tripped node policy — so
    per-VM tuning was impossible there at all. A tuning-only request now keeps the
    node's backend; policy still governs backends a caller chooses.
  • A missing bridge helper broke the node. bridge_helper() hard-failed the
    launch when none of three hardcoded paths existed on the VMM's filesystem.
    With vhost defaulting on, a working bridge node whose helper lives elsewhere
    would have lost every VM on upgrade — the same mistake the /dev/vhost-net
    probe was already corrected for. It now falls back to the non-vhost bridge
    netdev with a warning.
  • Warning on every launch. The "user mode supports neither vhost nor
    multiqueue" warning tested the resolved value, and the shipped vmm.toml
    sets mode = "user" with vhost = true, so it fired on every launch of a
    stock node. Inheritance being ignored there is the documented design, not a
    silent skip; the warning is gone.
  • Node tuning was pinned into the manifest. resolve_requested_networks
    persisted the merged values, so setting vhost = false node-wide to roll back
    reached VMs deployed with no override but not those deployed with
    --net bridge. Only what a deployment explicitly asks for is recorded now;
    identity-bearing fields are still pinned as before.
  • max_net_queues was unvalidated. 0 rejected even queues = 1, and
    128 let a request past the node cap only to be rejected against the
    different MAX_NET_QUEUES = 64 bound.

Re-verified on phala-tdx-lab after the fixes:

--net-queues 4 with no --net   → tap,id=net0,ifname=dt…,vhost=on,queues=4
                                 virtio-net-pci,…,mq=on,vectors=10
manifest networks              → {"mode":"bridge",…,"queues":4}   # no "vhost" key
node vhost = false, restart    → runtime-networks {"vhost":false,"queues":4}
fresh VM, vhost=off + 4 queues → tap,…,vhost=off,queues=4 ; guest combined = 4
                                 1 vhost thread (vsock only) ; multi_queue numqueues 4
libvirt mode, empty filter     → cvm.network_filter.filter must not be empty…
max_net_queues = 0             → cvm.max_net_queues must be between 1 and 64

One unrelated observation from that run: these lab VMs exit after a stop/start
cycle, with dstack-prepare.service failing on the second boot. A control VM on
plain user networking — whose netdev string this PR leaves byte-for-byte
unchanged — reproduces it identically, so it is a property of the test app
(key_provider: none), not of this change.

Second review pass

Four more, all fixed:

  • netd could not tear down an unfiltered TAP without libvirt. remove_interface
    decided whether to delete an nwfilter binding by checking that /usr/bin/virsh
    exists. On a mode = "none" node that has virsh installed but no reachable
    libvirtd, delete_binding fails on anything but the literal "binding not
    found", and prepare_bridge starts by calling remove_interface — so every
    multiqueue bridge VM would fail to start and leak its TAP. Remove/Check now
    carry whether the interface was created with a binding, defaulting to true on
    the wire so an older VMM's removals still clean up.
  • Check reported healthy unfiltered TAPs as broken, since it ran
    nwfilter-binding-dumpxml for every non-macvtap interface. Same flag.
  • netd version skew was invisible. netd is a separately deployed root
    service, and queues is #[serde(default)], so an older netd silently built a
    single-queue TAP while the VMM emitted queues=N — the exact IFF_MULTI_QUEUE
    mismatch the adjacent comment warns about, surfacing only as a QEMU failure
    inside the launcher. netd now echoes the queue count it created and the VMM
    refuses to launch on a mismatch.
  • Tuning-only requests pinned a backend they were not allowed to choose. The
    first-pass fix let {"queues": 2} inherit the node's backend and skip
    allowed_network_modes, but resolve_requested_networks then pinned that
    mode, parent, and bridge into the manifest — so tuning was strictly more
    powerful than naming the backend, and a later node change no longer reached
    the VM. The rule is now uniform: pin what the caller named, inherit the rest.

Re-verified on phala-tdx-lab with virsh installed and libvirt_uri pointed at
a dead socket, which is the configuration that used to fail:

virsh --connect qemu+unix:///system?socket=/nonexistent  → failed to connect to the hypervisor

deploy --net bridge --net-queues 4
  → tap,id=net0,ifname=dte333c1530de5,…,vhost=on,queues=4 + mq=on,vectors=10
  → tun type tap … multi_queue numqueues 4 persist on user kvin
  → netd: prepared TAP … filter= queues=4
remove
  → netd: removed managed network interface … ; no leftover dt* interfaces

And the filtered path is unchanged, against a real libvirt:

deploy --net bridge (mode = "libvirt")
  → nwfilter-binding-list: dt1f60d8a14a79   clean-traffic
remove
  → binding deleted ; no leftover interfaces

Final regression on phala-tdx-lab

47 assertions against the final binary on a real TDX host (kernel 6.8, QEMU
8.2.2+tdx1.1, guest dstack-0.6.0), on a dedicated bridge and VMM instance —
config validation, RPC policy, generated command lines, host interface state,
guest-visible state, node rollback, and teardown. All pass.

A  config validation      max_net_queues 0 / 65 rejected; node queues 65 rejected;
                          libvirt mode with an empty filter rejected
B  RPC policy             queues 9 > cap rejected; user+multiqueue rejected;
                          queues 8 at the cap accepted -> 8 net vhost threads + vsock
C1 bridge default         tap,br=…,helper=/usr/lib/qemu/qemu-bridge-helper,vhost=on
                          device line unchanged, no mq=on; 1 net vhost thread
C2 --net-no-vhost         bridge,id=net0,br=dstack-perf0 ; vsock vhost thread only
C3 --net-queues 4         tap,ifname=dt…,vhost=on,queues=4 + mq=on,vectors=10
                          netd tap: multi_queue numqueues 4 ; 4 net vhost threads
C4 macvtap --net-queues 2 tap,fds=3:4,vhost=on + mq=on,vectors=6
                          fd 3 and fd 4 both -> /dev/tap1783 ; numtxqueues 2 numrxqueues 2
C5 --net-queues 2 alone   inherits the node backend: tap,ifname=dt…,vhost=on,queues=2
                          manifest: {"mode":"bridge","bridge":"","queues":2}  <- nothing pinned
G  guest ethtool -l       c1=1  c2=1  c3=4  c5=2 channels; all four got DHCP leases
D  node vhost=false       a VM deployed with --net-queues 4 comes back as
                          vhost=off,queues=4 with no net vhost threads
E  libvirt unreachable    unfiltered multiqueue VM deploys, runs and tears down;
                          netd never invokes virsh; no unowned interface left

Throughput on the final binary, 64-byte UDP host→guest via kernel pktgen, same
CVM shape on the same host:

offered c1: helper + vhost, 1q c2: legacy netdev, no vhost b3: netd + vhost, multiqueue
200k 0.16% loss, main 0% 0.19% loss, main 88% 0% loss, main 0%
500k 8.7% loss, main 0% 1.4% loss, main 94% 0% loss, main 0%
1M 0% loss, main 0%

The main thread going 88–94% → 0% is the wall coming down. The c1 column is the
nuance stated above: with one queue vhost relocates the ceiling into the guest's
receive queue rather than removing it (guest CPU 121% vs 99% in the 500k row),
which is what the multiqueue column fixes.

One observation worth recording: that multiqueue VM had 2 vCPUs, and although
QEMU was given queues=8,vectors=18 the guest reported Combined: 2. The
virtio-net driver uses at most one queue pair per vCPU. Over-provisioning is
therefore inert rather than harmful, and it is not rejected at deployment
because resize can raise the vCPU count later; this is now documented.

Default queue count

Queue pairs default to min(vcpu, 16). Two consequences are worth stating
plainly, because they are behaviour changes on upgrade rather than opt-ins:

  • Every VM with two or more vCPUs gets a different virtio-net device. It
    gains mq=on,vectors=2N+2 and N queue pairs where it previously had one. The
    device is not measured — mr_config_id covers the compose hash and instance
    info — so app identity is unaffected, but the guest does see a different NIC.
  • Bridge nodes need netd to get it. qemu-bridge-helper returns a single
    descriptor and cannot create a multi_queue TAP. A node that has never
    deployed netd does not fail: bridge NICs fall back to one queue pair with a
    warning. A deployment that asked for a queue count explicitly still fails, so
    the caller learns their request was not met rather than silently getting less.

The measured trade-off, from an 8-vCPU TDX CVM with only the guest's channel
count changed (ethtool -L), is real in both directions:

queue pairs short-connection throughput
1 22.3k conn/s
2 ~20k conn/s
4 15–21k conn/s
8 6.2–7.7k conn/s

The same CVM moved 3.0 Mpps of 64-byte UDP with no loss at 8 queues against
roughly 600k at one. Bandwidth-bound workloads want the default; a VM serving
many short connections should set --net-queues 1. Cross-vCPU wakeups cost an
IPI and a VM exit under TDX, which is why the scaling is capped at 16 rather
than following large vCPU counts.

Verified on phala-tdx-lab, 23 assertions:

[cvm.networking] queues = 4   -> rejected: "has been removed"
 1 vCPU  -> helper netdev, no mq=on                    guest combined = 1
 4 vCPU  -> queues=4,  mq=on,vectors=10, 4+1 threads   guest combined = 4
20 vCPU  -> queues=16, mq=on,vectors=34, 16+1 threads  guest combined = 16
 8 vCPU --net-queues 1 -> helper netdev, no mq=on      guest combined = 1
 8 vCPU --net user     -> user netdev, no mq=on
no netd, default        -> helper netdev, one queue, warning logged
no netd, --net-queues 4 -> deploy fails naming netd

Surfaces and interactions

  • vhost off turns the multiqueue default off too. Without vhost the QEMU main
    loop drains every queue on one thread, so extra queues buy little while still
    costing a netd interface, more MSI-X vectors, and a changed guest device.
    Anyone disabling vhost wants the old data plane, so they get the old shape.
    An explicit queue count is still honoured without vhost, since that
    combination is a deliberate request rather than a default.
  • Raising the request ceiling does not raise the default. max_net_queues
    bounds what a deployment may ask for; the default's own cap is a fixed 16, so
    a larger VM never silently acquires a worse default. Hard ceiling from any
    source is 64.
  • UpdateVm changes both fields, applying from the VM's next boot.
    vmm-cli.py update grew --net, --net-vhost / --net-no-vhost and
    --net-queues to reach it; the update path previously had no networking
    options at all.
  • The web UI exposes both per NIC in the deploy and update dialogs, next to
    the mode selector. Leaving a control on its default emits no field, so the
    node keeps owning that value.

Verified on phala-tdx-lab (22 assertions):

8 vCPU --net-no-vhost           -> bridge,id=net0,br=…   no queues=, no mq=on, vsock vhost thread only
8 vCPU --net-no-vhost --net-queues 4 -> vhost=off,queues=4 + mq=on,vectors=10
24 queues, max_net_queues=16    -> rejected "must not exceed 16"
   after raising it to 32       -> accepted; queues=24, vectors=50
24 vCPU default, max=32         -> queues=16   (the default's cap did not move)
UpdateVm --net-queues 2 --net-no-vhost, restart -> vhost=off,queues=2, vectors=6
GetMeta.networking.max_queues   -> present for the UI to bound its input
UI payload {vhost:false,queues:2} -> accepted, applied
UI payload with both left unset   -> vCPU default, and neither field written to the manifest

Third review pass

Eight more, all fixed. Three would have stopped a working node from launching
VMs, which is the failure mode a defaulted-on feature has to be judged by:

  • cvm.max_net_queues never bounded the default. Lowering it to 2 still
    handed a 16-vCPU VM sixteen queue pairs, contradicting both the vmm.toml
    comment and the docs. Raising it above 16 still only widens what a caller may
    request; lowering it below 16 now lowers the default too, because a node that
    refuses a request for four should not hand out sixteen by itself.
  • One-shot dstack-vmm run broke on every bridge VM. It never applied the
    netd fallback, so with the default queue count a ≥2-vCPU bridge NIC hard-failed
    with "does not manage netd interface lifecycle" on a node that worked before.
  • A dead netd made every bridge VM fail to start. Availability was probed
    with socket.exists(), but netd does not unlink its socket on shutdown, so a
    stale file read as "netd is here" and the launch then failed to connect
    instead of falling back. It now connects, the same way netd's own
    bind-time staleness check does.
  • Two failure paths leaked a TAP. The new queue-mismatch bail! and the
    pre-existing response.device ? returned without the rollback loop, so a
    version-skewed netd left an interface on the bridge with nothing recorded to
    clean it up. Both now unwind through one shared rollback.
  • Legacy VMs could not be stopped. The two "infer missing runtime networks"
    paths still called resolved_networks() rather than the clamped
    runtime_networks(), giving an already-running VM an unclamped queue count
    and failing stop_vm with a netd connect error.
  • GetInfo reported a data plane the NIC did not get. A bridge NIC that
    fell back to the non-vhost bridge netdev for want of qemu-bridge-helper
    still reported vhost: true. The QEMU arguments and the reported status now
    read the same effective_vhost.
  • The UI sent values it had hidden. Setting a queue count and then switching
    the NIC to user mode still submitted it — the deploy failed with no visible
    control to fix. queues/vhost are now scoped to non-user modes, like
    bridge_name.
  • vmm-cli update --net-* replaced the whole NIC list, dropping extra
    interfaces and un-pinning a bridge. It now merges into the VM's existing NIC
    and refuses outright on a multi-NIC VM rather than guessing.

Fixing that last one surfaced a round-trip bug worth calling out on its own:
GetInfo reported parent and macvtap_mode on every interface, including
bridge NICs that had merely inherited them from [cvm.networking] — and the
deployment RPC rejects parent outside macvtap mode. Reported configuration
could be read but not sent back. Both fields are now scoped to macvtap the way
bridge_name is scoped to bridge, with a test that a reported interface
satisfies the RPC's own validation.

Verified on phala-tdx-lab (11 assertions):

16 vCPU, max_net_queues=2      -> queues=2         (the default follows the ceiling down)
stale /run/.../netd.sock       -> deploy succeeds, helper netdev, one queue, warning logged
update --net-queues 2 --net-no-vhost on a bridge VM
                               -> manifest keeps bridge=dstack-perf0, gains vhost:false queues:2
                               -> restart: tap,…,vhost=off,queues=2
GetInfo interfaces             -> {"vhost": false, "queues": 2}   matching the running QEMU
GetInfo configuration          -> {"mode":"bridge","bridge_name":"dstack-perf0","parent":""}
                                  i.e. round-trippable back into UpdateVm

Fourth and fifth review passes

Fourteen more findings, all fixed, and the shape of them says something about
the first three passes: nine came from the fixes those passes made.

Two root causes accounted for most of it, and both were the same mistake --
recomputing, from configuration an operator can edit at any time, a fact that
was only true at the moment something was built:

  • Teardown re-derived whether netd owned an interface. Turning
    network_filter.mode off while VMs existed orphaned TAPs and leaked their
    nwfilter bindings. Interface names are a deterministic hash of the VM
    identity, so the same VM comes back on the same name and inherits the
    leftover ebtables rules -- silently filtered traffic on a NIC the operator
    believes is unfiltered. Reproduced on a TDX host against the pre-fix build:
    bindings after removal: dt77a0317f8bca clean-traffic. NICs now record what
    netd built for them.
  • A tuning-only deployment pinned the node's backend into its manifest. It
    then stopped following the node, including into a mode node policy never let
    that caller choose. Networking now carries inherit_mode, and an inherited
    entry reports an empty mode, which is what it was deployed with.

GetInfo output could not be sent back. Its configuration is the input
UpdateVm takes, and both vmm-cli and the web UI read it, change one field,
and resend the rest. The node's own bridge was refused by the allowed_bridges
gate, and macvtap_mode was refused outright -- so vmm-cli update --net-*
failed on every bridge VM on a default-configured node and on every macvtap VM.
There is now one test that asserts the property over every mode and tuning
combination, rather than the one example each earlier pass fixed:

BRIDGE  ROUNDTRIP = Err("bridge_name 'br-node' is not allowed by node policy")
MACVTAP ROUNDTRIP = Err("macvtap_mode is node-controlled ...")

The rest: a stopped VM reported the NICs of a finished boot rather than the
ones its next launch would build; delete_binding was the one netd helper that
spawned virsh unbounded, and best-effort cleanup had just put it on every
prepare, where one unreachable libvirt could stall netd's serialized loop for
every other VM; status polling probed netd once per stopped VM, from inside the
global state lock, costing netd two warnings per probe; a queue count was
refused for a backend the caller inherited, which left the VM uneditable once
its node moved; the web UI could silently delete a NIC and renumber the rest,
changing their MAC addresses; and netd stopped answering requests it could
not parse, which is exactly what a VMM newer than its netd needs to read.

Verified on a TDX host: 21 assertions across the deployment, teardown,
inheritance and reporting paths, each one first shown to fail against the
pre-fix binary.

Sixth and seventh review passes

Twenty more, and the character of them finally changed: no data-plane or
teardown correctness left, but a long tail in the surfaces an operator actually
touches, and a lot of documentation that had drifted behind the code.

The knobs were unobservable. vmm-cli.py info printed nothing about
networking at all, and the web UI's interface panel was never extended, so the
two status fields this PR adds — the effective vhost state and queue count —
could only be read as raw JSON. That matters because both silently degrade: a
node without netd drops every defaulted bridge NIC to one queue pair, and a
missing qemu-bridge-helper drops it off vhost. Both now appear in info and
in the UI, with a note when the VM is not running and the numbers are a
prediction.

vmm-cli.py update --net <mode> could not change a mode. It merged the
NIC's current fields and overwrote only mode, so switching a bridge VM to user
sent a bridge_name along with it and was refused for a field the caller never
typed and had no flag to clear. Mode-owned fields are now dropped on a switch.
The same shape had a second cause: a VM pins its bridge for life, and once the
operator moved the node's own default, the VM's reported configuration stopped
being accepted back. An update may now restate what its own VM already holds.

The knobs could be set but not unset--net-queues auto and
--net-vhost-default now exist, --net-queues 0 is refused with a sentence
instead of being silently discarded, and --net-queues -1 no longer surfaces a
serde error naming a column offset.

Two more instances of the pattern this PR keeps hitting — deciding something
at launch and then recomputing it later from configuration that has moved:
GetInfo recomputed each interface's vhost state, so an edit to
cvm.qemu_bridge_helper changed what a running VM was said to be using. The
data plane is now settled once at launch and written down, and VmInfo::to_pb
no longer takes a CvmConfig at all. And the single-queue fallback fired for
libvirt-filtered bridges, where it cannot help — filtering needs netd whatever
the queue count — reporting a shape no launch could produce.

Also: GetMeta advertised modes node policy forbids, so the deploy dialog
offered choices whose only outcome was "not allowed by node policy"; a queue
count or vhost request is now refused for a backend the caller chose and
accepted-but-dormant for one they inherited, since only the first is theirs to
correct; and a failed multiqueue prepare says a separately deployed netd may
predate multiqueue support, which the version-skew message previously missed in
the default unfiltered configuration.

Fourteen documentation claims were false or misleading, checked line by line
against the code: the queue-refusal rule was stated backwards after the code
changed under it, "byte for byte identical" applied only to the guest device
line and not the netdev, queues was described as a per-node setting it has
never been, the netd TAP form was missing from the bridge row that produces it
most often, and the pre-6.4 cgroup claim was wrong in the direction that
matters. Several code comments had drifted the same way.

Operator notes

  • The account running QEMU must be able to open /dev/vhost-net
    (root:kvm 0660 — add it to kvm). On prod7 this was already true and the
    module autoloaded on first open; no root, no pre-created TAPs, no
    CAP_NET_ADMIN. If it is not, QEMU exits at startup and the VM does not
    boot.
  • On host kernels older than 6.4 the vhost worker is a free-standing kernel
    thread whose CPU time escapes the VM's cgroup. Since 6.4 it is a vhost_task
    inside the QEMU thread group — verified on the 6.8 lab host, where the worker
    tids appear under /proc/<qemu>/task/ with the same cgroup — so cpu.max
    and cgroup accounting still attribute it to the CVM.
  • Bridge nodes that want more than one queue pair must run netd, including
    when network_filter.mode = "none".
  • --net-no-vhost restores the previous -netdev bridge,... command line
    exactly, so the old behaviour is one flag away.

Copilot AI lite review requested due to automatic review settings August 26, 2026 07:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kvinwang
kvinwang force-pushed the feat/vmm-vhost-multiqueue branch 8 times, most recently from a350589 to 0d3ad45 Compare August 26, 2026 12:17
@Leechael

Copy link
Copy Markdown
Collaborator

Reviewed against next and read the netd/VMM paths end to end. Design and the on-host verification look right; the points below are the ones that would bite us on rollout.

1. max_net_queues does not bound the default.
Networking::default_queue_pairs() (config.rs:1015) is vcpu.clamp(1, DEFAULT_QUEUE_SCALING_CAP) and never reads cvm.max_net_queues; validate_resolved_network only checks the hard 64. With max_net_queues = 4, --net-queues 5 is rejected while a 16-vCPU VM deployed without the flag gets 16 queue pairs. vmm.toml:57-58 ("default to the VM's vCPU count, capped at this value") and the description say the opposite. Since cvm.networking.queues is rejected at config load, an operator has no node-level way to bound the default short of vhost = false. Suggest vcpu.clamp(1, DEFAULT_QUEUE_SCALING_CAP.min(cfg.max_net_queues)), which keeps the "raising the ceiling does not raise the default" intent.

2. Queue-echo mismatch leaks the prepared interface; upgrade order is undocumented.
In prepare_filtered_networks the rollback loop only runs on the netd::request error arm. The two post-prepare exits — missing macvtap device (app.rs:638) and the queues echo check (app.rs:644) — bail after netd has already created the interface, without Remove and before set_runtime_networks, so stop/remove never see it. Each rejected start leaves one TAP per NIC behind (plus the earlier NICs already in prepared).
The trigger is the skew you describe in the follow-ups: dstack-netd.service is a separate long-running process, so upgrading and restarting dstack-vmm alone leaves the old netd answering. On such a node every bridge (libvirt mode) or macvtap VM with more than one vCPU is refused at start until netd is restarted. Fail-closed is the right call; please route these two exits through the same rollback, and add an upgrade note (restart netd before or together with the VMM) to docs/network-data-plane.md — none of the docs mention it today.

3. Existing nodes change their QEMU command line on upgrade with no config change.
vhost is Option<bool> with unwrap_or(true), so a vmm.toml that predates this PR turns vhost on. On a stock bridge node (network_filter.mode = none, no netd socket, helper at /usr/lib/qemu/qemu-bridge-helper) the netdev goes from bridge,id=…,br=X to tap,id=…,br=X,helper=…,vhost=on; QEMU then exits if the QEMU user cannot open /dev/vhost-net, and the VMM only checks that the node exists. Nodes running netd additionally move every multi-vCPU libvirt-bridge/macvtap VM to multiqueue on restart. I understand this is intentional; the description should state it as an upgrade step (verify /dev/vhost-net access as the QEMU user, or set vhost = false first), and I'd argue the safer default for a release is vhost = false with the on-by-default flip as a separate change.

4. update_networking replaces networks wholesale.
A request that changes mode without queues/vhost clears the previous per-VM tuning; a request with only tuning stores the node's current backend in the manifest. The UI round-trips the stored values so it is unaffected, but an RPC caller has to read-modify-write. Not a defect, but worth one sentence in the proto comments ("unset means cleared, not kept").

Context that may be useful in the docs:

  • Runtime vhost_net_start failure does not exit: QEMU logs falling back on userspace virtio and continues on the userspace backend, and vhostforce does not change that. Worth listing next to the vhost thread-count check as something to alert on.
  • The guest kernel has no swiotlb= and CONFIG_SWIOTLB_DYNAMIC off, so the bounce pool is a fixed clamp(6% RAM, 64 MB, 1 GB). The default queue count follows vCPUs while the pool follows RAM; a 16 vCPU / 4 GB shape gets 246 MB. Your 8 vCPU / 8 GiB lab shape does not exercise it.

Separately, port_map on a non-user NIC is still accepted and silently dropped (only hostfwd_index consumes it). Not this PR's scope; I'll follow up.

@Leechael

Copy link
Copy Markdown
Collaborator

Reference notes on how clouds pick a queue count — background for later tuning. dstack operators set --net-queues on the node or the VM; Phala Cloud will fill the same field from an instance type.

16 is a tuning convention, not a spec limit

Layer Cap Source
virtio / Linux uapi VIRTIO_NET_CTRL_MQ_VQ_PAIRS_MAX = 0x8000 virtio_net.h
Linux TAP (4.x+) MAX_TAP_QUEUES = 256 if_tap.h
TAP on 3.x 8 Nova #1847367
RHEL 8/9 advice one pair per vCPU, up to 16 RHEL 9, RHEL 8
RHEL 7 1–256, no 16 cap RHEL 7
QEMU cmdline queues=N, vectors=2N+2 linux-kvm Multiqueue

RHEL's "up to 16" is host-thread budget (one vhost-net worker per pair) plus diminishing returns. Same reason a default cap of 16 can sit below max_net_queues.

How clouds encode it on the instance type

They publish default per NIC, max per NIC, and often an instance-wide quota. The SKU fills the hypervisor knob.

AWS ENA — per SKU three columns. General-purpose default plateaus at 8; network-optimized default goes to 16/32. Formula: min(MAX_NUM_QUEUES_PER_ENI, vCPUs), where MAX_NUM_QUEUES_PER_ENI is 8 for most types and 32 for network-accelerated. Single ENI cannot exceed the instance vCPU count. 2025: queues are a pool allocatable across ENIs.

GCP — formula from machine type + NIC driver, override at create.

Aliyun ECS — closest to a SKU column. Bind-to-type applies the default; API exposes PrimaryEniQueueNumber, MaximumQueueNumberPerEni, TotalEniQueueQuantity. U-instance is roughly min(vCPU, 16). Stopped ENI can change within those two quotas; guest ethtool -L can lower further (lost on reboot).

OpenStack Novamin(vcpus, max_queues). max_queues is operator config (legacy 1/8/256 by kernel major); flavor extra spec only enables/disables MQ.

Azure — SKU capability is Accelerated Networking (SR-IOV), not virtio queue count. Different dataplane.

Practices that show up across those sources

Lined up with the numbers already in this PR (short connections 22k→6k conn/s from 1q to 8q; 64B UDP ~600k→3.0 Mpps). TDX makes the left-hand side worse than plain KVM: a cross-vCPU wakeup is an IPI plus a VM exit.

  1. One pair per vCPU is the useful maximum, not the useful default. Extra pairs above vCPU are inert (guest virtio_net uses min(vcpu, advertised)). Too few pairs on a PPS-bound VM leave a single guest RX queue as the ceiling.
  2. Default and max are different columns. AWS general-purpose defaults plateau at 8 on a 32-vCPU SKU; the max is 32. Aliyun's U-instance uses min(vCPU, 16) for both. Raising the request ceiling is not the same as retuning every VM's default.
  3. Workload picks the number, not vCPU count.
    • many short TCP / RPC / conntrack churn → keep 1 (or a small N)
    • many concurrent flows / high PPS / large-UDP → raise toward min(vcpu, 8) and measure; 16 only if the host can afford the vhost threads
    • bandwidth with few large TSO flows saturates with a handful of pairs; more queues do not buy Gbps
  4. Compute-shaped SKUs stay conservative; network-shaped SKUs raise the default. AWS states this directly (ENA best practices: more queues for network-intensive apps, fewer for CPU-intensive).
  5. Instance-wide quota once there is more than one NIC. AWS / GCP / Aliyun cap Σ queues, not only per NIC.
  6. virtio + vhost-net costs more on the host than ENA / gVNIC / Azure VF. Each pair is a vhost-net worker. A request ceiling of 32 can exist without the SKU default being 32.
  7. Who sets the knob.
    • dstack operator: --net-queues / max_net_queues after measuring the VM
    • Phala Cloud: default_queues (and later max_queues) on the instance type; CreateVm sends that value. A plausible fill is min(vcpu, family_default_cap) — general/compute ≈ 8, a later network family ≈ 16

@kvinwang
kvinwang force-pushed the feat/vmm-vhost-multiqueue branch from 0d3ad45 to fd756b8 Compare August 26, 2026 17:00
@kvinwang
kvinwang force-pushed the feat/vmm-vhost-multiqueue branch from fd756b8 to 2452f6f Compare August 26, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants